Skip to content

Conversation

typotter
Copy link

@typotter typotter commented Oct 9, 2025

PR #2923: [flags] Generic resolve method

What does this PR do?

This PR implements the generic resolve<T>() method providing OpenFeature-compatible flag resolution with detailed error information, metadata, and variant tracking.

Key additions:
public API

  • Generic resolve<T>(flagKey, defaultValue) method that returns ResolutionDetails<T>
  • ResolutionDetails<T> data class containing value, variant, reason, error code, error message, and flag metadata
  • ErrorCode enum with OpenFeature-spec error codes (FLAG_NOT_FOUND, TYPE_MISMATCH, PARSE_ERROR, etc.)

internal

  • FlagValueConverter object for centralized, side-effect-free type conversion
  • Enhanced error handling and type mismatch detection
  • Improved internal architecture with InternalResolution sealed class

Motivation

OpenFeature Specification Alignment

The OpenFeature specification defines a resolve() method as the core evaluation API, providing rich resolution details beyond just the flag value. This enables:

  • Error handling: Applications can detect and handle flag resolution failures (missing flags, type mismatches, parse errors)
  • Variant tracking: Track which variant of a flag was returned for experimentation
  • Metadata support: Access additional flag metadata for analytics and debugging
  • Debugging: Detailed resolution reasons help troubleshoot flag behavior

Additional Notes

Anything else we should know when reviewing?

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@typotter typotter changed the base branch from develop to feature/feature-flagging October 9, 2025 08:03
@typotter typotter changed the base branch from feature/feature-flagging to typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint October 9, 2025 08:03
@codecov-commenter
Copy link

codecov-commenter commented Oct 9, 2025

Codecov Report

❌ Patch coverage is 89.03226% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.85%. Comparing base (a70a89e) to head (4a3b1c4).
⚠️ Report is 7 commits behind head on feature/feature-flagging.

Files with missing lines Patch % Lines
.../flags/featureflags/internal/FlagValueConverter.kt 71.43% 3 Missing and 5 partials ⚠️
.../flags/featureflags/internal/DatadogFlagsClient.kt 93.46% 0 Missing and 7 partials ⚠️
...oid/flags/featureflags/internal/NoOpFlagsClient.kt 0.00% 2 Missing ⚠️
Additional details and impacted files
@@                     Coverage Diff                      @@
##           feature/feature-flagging    #2923      +/-   ##
============================================================
+ Coverage                     70.76%   70.85%   +0.08%     
============================================================
  Files                           835      840       +5     
  Lines                         30388    30554     +166     
  Branches                       5132     5163      +31     
============================================================
+ Hits                          21504    21647     +143     
- Misses                         7431     7451      +20     
- Partials                       1453     1456       +3     
Files with missing lines Coverage Δ
.../datadog/android/flags/featureflags/FlagsClient.kt 35.58% <ø> (-0.59%) ⬇️
...flags/featureflags/internal/RumEvaluationLogger.kt 100.00% <100.00%> (ø)
...reflags/internal/model/PrecomputedFlagConstants.kt 100.00% <100.00%> (ø)
...otlin/com/datadog/android/flags/model/ErrorCode.kt 100.00% <100.00%> (ø)
...m/datadog/android/flags/model/ResolutionDetails.kt 100.00% <100.00%> (ø)
...oid/flags/featureflags/internal/NoOpFlagsClient.kt 76.19% <0.00%> (-8.02%) ⬇️
.../flags/featureflags/internal/DatadogFlagsClient.kt 94.17% <93.46%> (+7.32%) ⬆️
.../flags/featureflags/internal/FlagValueConverter.kt 71.43% <71.43%> (ø)

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@typotter typotter force-pushed the typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint branch from 2b5bcf6 to 211e111 Compare October 14, 2025 20:37
@typotter typotter force-pushed the typo/FFL-1184-resolve-value-method branch from 77e34af to 287fa8a Compare October 15, 2025 07:05
Base automatically changed from typo/FFL-1112-flagging-proxy-custom-precomputed-assignments-endpoint to feature/feature-flagging October 16, 2025 09:50
# Conflicts:
#	features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/featureflags/FlagsClient.kt
#	features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/featureflags/internal/DatadogFlagsClient.kt
#	features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/featureflags/internal/DatadogFlagsClientTest.kt
@typotter typotter force-pushed the typo/FFL-1184-resolve-value-method branch from be2f0e8 to 626593b Compare October 20, 2025 04:13
@typotter typotter marked this pull request as ready for review October 20, 2025 06:10
@typotter typotter requested review from a team as code owners October 20, 2025 06:10
@typotter typotter requested a review from jonathanmos October 20, 2025 06:10
is Int -> "Int"
is Double -> "Double"
is JSONObject -> "JSONObject"
else -> defaultValue!!::class.simpleName ?: "Unknown"
Copy link
Member

@jonathanmos jonathanmos Oct 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theoretically someone could pass null for T. In the current codepaths this would possibly be blocked at isTypeCompatible, but I think it's probably better to avoid the !!. We could do something like:

defaultValue?.let { it.class.simpleName ?: "Unknown" } ?: "Unknown"

or even directly return "Unknown" depending how important it is to know the exact class name.

fun resolveDoubleValue(String, Double): Double
fun resolveIntValue(String, Int): Int
fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject
fun <T> resolve(String, T): com.datadog.android.flags.model.ResolutionDetails<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specification says that even for concrete methods above we need to return ResolutionDetails https://openfeature.dev/specification/sections/providers, but we are returning just the objects. Is it fine to not use this structure there?

* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return [ResolutionDetails] containing the value, variant, reason, error info, and metadata.
*/
fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we support nullable types (values) here? If it is not the case, type should be T : Any

* @param flagKey The name of the flag to query. Cannot be null.
* @param defaultValue The value to return if the flag cannot be retrieved.
* @return The string value of the flag, or the default value if unavailable.
* @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param defaultValue The value to return if the flag cannot be found resolved for any reason. Cannot be null.
* @param defaultValue The value to return if the flag cannot be found or resolved for any reason. Cannot be null.

Comment on lines 148 to 149
private fun <T> resolveValue(flagKey: String, defaultValue: T): T =
resolveTracked(resolveInternal(flagKey, defaultValue))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is confusing: here we have 3 resolveXXX methods, and the fact that there is resolveInternal with a result passed to resolveTracked doesn't add clarity. Should resolveInternal be renamed, maybe?

* @param defaultValue The value to return if the flag cannot be retrieved or parsed.
* @return [ResolutionDetails] with either the parsed value and metadata, or an error.
*/
override fun <T> resolve(flagKey: String, defaultValue: T): ResolutionDetails<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor: it is better to move this up, so that we have interface methods grouped first and only after them we have private methods.

* @param extraLogging The JSONObject containing additional flag metadata.
* @return An immutable map of metadata, or null if the JSONObject is empty.
*/
private fun extractMetadata(extraLogging: JSONObject): Map<String, Any>? {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to explicitly make return type nullable? Cannot we return empty collection if JSONObject is empty?

) {
// Catch all conversion exceptions (including JSONException) and return null
// The caller is responsible for logging errors as appropriate
null
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe it is worth to log it with MAINTAINER target?

fun <T> isTypeCompatible(variationType: String, defaultValue: T): Boolean = when (defaultValue) {
is Boolean -> variationType == VariationType.BOOLEAN.value
is String -> variationType == VariationType.STRING.value
is Int -> variationType == VariationType.INTEGER.value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we also check for VariationType.NUMBER here as below?

@Suppress("UNCHECKED_CAST")
private fun <T> getConverterForType(defaultValue: T): (String) -> T? = when (defaultValue) {
is Boolean -> { s: String -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T }
is String -> { s: String -> s as? T }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this one can be just { s: String -> s }: input and output types are the same, to cast is not needed

Comment on lines -33 to +34
DOUBLE("double"),
JSON("json")
NUMBER("number"),
FLOAT("float"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we still have the usage of double term in API, should we keep it here? Or is it the change per spec?

* @property flagMetadata Optional map of arbitrary metadata associated with the flag (string keys, primitive values).
*/
data class ResolutionDetails<T>(
val value: T,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can value be null? If not, it is better to do data class ResolutionDetails<T: Any>

if (flagsConfiguration.rumIntegrationEnabled && resolution.flag.doLog) {
logEvaluation(
key = resolution.flagKey,
value = resolution.value as Any
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If value is never null, you should make T: Any everywhere like I described here https://github.com/DataDog/dd-sdk-android/pull/2923/files#r2444011674

/**
* Error codes for flag resolution failures, aligned with the OpenFeature specification.
*/
enum class ErrorCode {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I only see that FLAG_NOT_FOUND, PARSE_ERROR and TYPE_MISMATCH are used in this PR. Will others be used in other PRs or are they not needed?

* @param T The type of the resolved flag value (Boolean, String, Int, Double, JSONObject).
* @property value The resolved flag value. This is always present, either from flag evaluation or the default value.
* @property variant Optional string identifier for the resolved variant (e.g., "control", "treatment").
* @property reason Optional string explaining why this value was resolved (e.g., "TARGETING_MATCH", "DEFAULT", "ERROR").
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

e.g., "TARGETING_MATCH", "DEFAULT", "ERROR"

Is it possible to make this a enum instead of a String?

* @param defaultValue The default value (used to infer the target type)
* @return The converted value, or null if types are incompatible or conversion fails
*/
@Suppress("UNCHECKED_CAST")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Suppress("UNCHECKED_CAST")

this is not needed here

* @return The converted value, or null if types are incompatible or conversion fails
*/
@Suppress("UNCHECKED_CAST")
fun <T> convert(variationValue: String, variationType: String, defaultValue: T): T? {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

You don't actually need a defaultValue here. You can pass the class itself using KClass.

internal object FlagValueConverter {

    fun <T : Any> convert(variationValue: String, variationType: String, resultType: KClass<T>): T? {
        if (!isTypeCompatible(variationType, resultType)) {
            return null
        }
        return try {
            doConvert(variationValue, resultType)
        } catch (
            @Suppress("TooGenericExceptionCaught", "SwallowedException")
            e: Exception
        ) {
            null
        }
    }

    fun isTypeCompatible(variationType: String, resultType: KClass<*>): Boolean = when (resultType) {
        Boolean::class -> variationType == VariationType.BOOLEAN.value
        String::class -> variationType == VariationType.STRING.value
        Int::class -> variationType == VariationType.INTEGER.value
        Double::class -> variationType == VariationType.NUMBER.value || variationType == VariationType.FLOAT.value
        JSONObject::class -> variationType == VariationType.OBJECT.value
        else -> false
    }
    
    @Suppress("UNCHECKED_CAST")
    private fun <T : Any> doConvert(s: String, resultType: KClass<T>): T? = when (resultType) {
        Boolean::class -> s.lowercase(Locale.US).toBooleanStrictOrNull() as? T
        String::class -> s as? T
        Int::class -> s.toIntOrNull() as? T
        Double::class -> s.toDoubleOrNull() as? T
        JSONObject::class -> JSONObject(s) as? T
        else -> null
    }

    fun getTypeName(type: KClass<*>): String = when (type) {
        Boolean::class -> "Boolean"
        String::class -> "String"
        Int::class -> "Int"
        Double::class -> "Double"
        JSONObject::class -> "JSONObject"
        else -> type.simpleName ?: "Unknown"
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants